'use strict'

import path from 'node:path'

import {shuffleArray} from './general-util.js'

export const parentSymbol = Symbol('Parent group')

export function updatePlaylistFormat(playlist) {
  const defaultPlaylist = {
    options: [],
    items: []
  }

  let playlistObj = {}

  // Playlists can be in two formats...
  if (Array.isArray(playlist)) {
    // ..the first, a simple array of tracks and groups;

    playlistObj = {items: playlist}
  } else {
    // ..or an object including metadata and configuration as well as the
    // array described in the first.

    playlistObj = playlist

    // The 'tracks' property was used for a while, but it doesn't really make
    // sense, since we also store groups in the 'tracks' property. So it was
    // renamed to 'items'.
    if ('tracks' in playlistObj) {
      playlistObj.items = playlistObj.tracks
      delete playlistObj.tracks
    }
  }

  const fullPlaylistObj = Object.assign(defaultPlaylist, playlistObj)

  return updateGroupFormat(fullPlaylistObj)
}

export function updateGroupFormat(group) {
  const defaultGroup = {
    name: '',
    items: []
  }

  let groupObj = {}

  if (Array.isArray(group[1])) {
    groupObj = {name: group[0], items: group[1]}
  } else {
    groupObj = group
  }

  groupObj = Object.assign(defaultGroup, groupObj)

  groupObj.items = groupObj.items.map(item => {
    // Check if it's a group; if not, it's probably a track.
    if (Array.isArray(item[1]) || item.items) {
      item = updateGroupFormat(item)
    } else {
      item = updateTrackFormat(item)

      // TODO: Should this also apply to groups? Is recursion good? Probably
      // not!
      //
      // TODO: How should saving/serializing handle this? For now it just saves
      // the result, after applying. (I.e., "apply": {"foo": "baz"} will save
      // child tracks with {"foo": "baz"}.)
      if (groupObj.apply) {
        Object.assign(item, groupObj.apply)
      }
    }

    item[parentSymbol] = groupObj

    return item
  })

  return groupObj
}

export function updateTrackFormat(track) {
  const defaultTrack = {
    name: '',
    downloaderArg: ''
  }

  let trackObj = {}

  if (Array.isArray(track)) {
    if (track.length === 2) {
      trackObj = {name: track[0], downloaderArg: track[1]}
    } else {
      throw new Error("Unexpected non-length 2 array-format track")
    }
  } else {
    trackObj = track
  }

  return Object.assign(defaultTrack, trackObj)
}

export function cloneGrouplike(grouplike) {
  const newGrouplike = {
    name: grouplike.name,
    items: grouplike.items.map(item => {
      if (isGroup(item)) {
        return cloneGrouplike(item)
      } else {
        return {
          name: item.name,
          downloaderArg: item.downloaderArg
        }
      }
    })
  }

  for (const item of newGrouplike.items) {
    item[parentSymbol] = newGrouplike
  }

  return newGrouplike
}

export function filterTracks(grouplike, handleTrack) {
  // Recursively filters every track in the passed grouplike. The track-handler
  // function passed should either return true (to keep a track) or false (to
  // remove the track). After tracks are filtered, groups which contain no
  // items are removed.

  if (typeof handleTrack !== 'function') {
    throw new Error("Missing track handler function")
  }

  return Object.assign({}, grouplike, {
    items: grouplike.items.filter(item => {
      if (isTrack(item)) {
        return handleTrack(item)
      } else {
        return true
      }
    }).map(item => {
      if (isGroup(item)) {
        return filterTracks(item, handleTrack)
      } else {
        return item
      }
    }).filter(item => {
      if (isGroup(item)) {
        return item.items.length > 0
      } else {
        return true
      }
    })
  })
}

export function flattenGrouplike(grouplike) {
  // Flattens a group-like, taking all of the non-group items (tracks) at all
  // levels in the group tree and returns them as a new group containing those
  // tracks.

  return {items: getFlatTrackList(grouplike)}
}

export function getFlatTrackList(grouplike) {
  // Underlying function for flattenGrouplike. Can be used if you just want to
  // get an array and not a grouplike, too.

  return grouplike.items.map(item => {
    if (isGroup(item)) {
      return getFlatTrackList(item)
    } else {
      return [item]
    }
  }).reduce((a, b) => a.concat(b), [])
}

export function getFlatGroupList(grouplike) {
  // Analogue of getFlatTrackList for groups instead of tracks. Returns a flat
  // array of all the groups in each level of the provided grouplike.

  return grouplike.items
    .filter(isGroup)
    .map(item => [item, ...getFlatGroupList(item)])
    .reduce((a, b) => a.concat(b), [])
}

export function countTotalTracks(item) {
  // Returns the total number of tracks in a grouplike, including tracks in any
  // descendant groups. Basically the same as flattenGrouplike().items.length.

  if (isGroup(item)) {
    return item.items.map(countTotalTracks)
      .reduce((a, b) => a + b, 0)
  } else if (isTrack(item)) {
    return 1
  } else {
    return 0
  }
}

export function shuffleOrderOfGroups(grouplike) {
  // OK, this is opinionated on how it should work, but I think it Makes Sense.
  // Also sorry functional-programming friends, I'm sure this is a horror.
  // (FYI, this is the same as how http-music used to work with shuffle-groups,
  // *if* you also did --collapse-groups first. That was how shuffle-groups was
  // usually used (by me) anyway, so I figure bringing it over (with simpler
  // code) is reasonable. The only potentially confusing part is the behavior
  // when a group contains both tracks and groups (the extra tracks in each
  // group are collected together and considered "leftover", and are treated as
  // their own ordered flat groups).

  // Shuffle the list of groups (and potentially tracks). This won't shuffle
  // the *contents* of the groups; only the order in which the whole list of
  // groups (and tracks) plays.
  const { items } = collapseGrouplike(grouplike)
  return {items: shuffleArray(items)}
}

export function reverseOrderOfGroups(grouplike) {
  const { items } = collapseGrouplike(grouplike)
  return {items: items.reverse()}
}

export function collectGrouplikeChildren(grouplike, filter = null) {
  // Collects all descendants of a grouplike into a single flat array.
  // Can be passed a filter function, which will decide whether or not to add
  // an item to the return array. However, note that all descendants will be
  // checked against this function; a group will be descended through even if
  // the filter function checks false against it.
  // Returns an array, not a grouplike.

  const items = []

  for (const item of grouplike.items) {
    if (filter === null || filter(item) === true) {
      items.push(item)
    }

    if (isGroup(item)) {
      items.push(...collectGrouplikeChildren(item, filter))
    }
  }

  return items
}

export function partiallyFlattenGrouplike(grouplike, resultDepth) {
  // Flattens a grouplike so that it is never more than a given number of
  // groups deep, INCLUDING the "top" group -- e.g. a resultDepth of 2
  // means that there can be one level of groups remaining in the resulting
  // grouplike, plus the top group.

  if (resultDepth <= 1) {
    return flattenGrouplike(grouplike)
  }

  const items = grouplike.items.map(item => {
    if (isGroup(item)) {
      return {items: partiallyFlattenGrouplike(item, resultDepth - 1).items}
    } else {
      return item
    }
  })

  return {items}
}

export function collapseGrouplike(grouplike) {
  // Similar to partiallyFlattenGrouplike, but doesn't discard the individual
  // ordering of tracks; rather, it just collapses them all to one level.

  // Gather the groups. The result is an array of groups.
  // Collapsing [Kar/Baz/Foo, Kar/Baz/Lar] results in [Foo, Lar].
  // Aha! Just collect the top levels.
  // Only trouble is what to do with groups that contain both groups and
  // tracks. Maybe give them their own separate group (e.g. Baz).

  const subgroups = grouplike.items.filter(x => isGroup(x))
  const nonGroups = grouplike.items.filter(x => !isGroup(x))

  // Get each group's own collapsed groups, and store them all in one big
  // array.
  const ret = subgroups.map(group => {
    return collapseGrouplike(group).items
  }).reduce((a, b) => a.concat(b), [])

  if (nonGroups.length) {
    ret.unshift({name: grouplike.name, items: nonGroups})
  }

  return {items: ret}
}

export function filterGrouplikeByProperty(grouplike, property, value) {
  // Returns a copy of the original grouplike, only keeping tracks with the
  // given property-value pair. (If the track's value for the given property
  // is an array, this will check if that array includes the given value.)

  return Object.assign({}, grouplike, {
    items: grouplike.items.map(item => {
      if (isGroup(item)) {
        const newGroup = filterGrouplikeByProperty(item, property, value)
        if (newGroup.items.length) {
          return newGroup
        } else {
          return false
        }
      } else if (isTrack(item)) {
        const itemValue = item[property]
        if (Array.isArray(itemValue) && itemValue.includes(value)) {
          return item
        } else if (item[property] === value) {
          return item
        } else {
          return false
        }
      } else {
        return item
      }
    }).filter(item => item !== false)
  })
}

export function filterPlaylistByPathString(playlist, pathString) {
  // Calls filterGroupContentsByPath, taking an unparsed path string.

  return filterGrouplikeByPath(playlist, parsePathString(pathString))
}

export function filterGrouplikeByPath(grouplike, pathParts) {
  // Finds a group by following the given group path and returns it. If the
  // function encounters an item in the group path that is not found, it logs
  // a warning message and returns the group found up to that point. If the
  // pathParts array is empty, it returns the group given to the function.

  if (pathParts.length === 0) {
    return grouplike
  }

  let firstPart = pathParts[0]
  let possibleMatches

  if (firstPart.startsWith('?')) {
    possibleMatches = collectGrouplikeChildren(grouplike)
    firstPart = firstPart.slice(1)
  } else {
    possibleMatches = grouplike.items
  }

  const titleMatch = (group, caseInsensitive = false) => {
    let a = group.name
    let b = firstPart

    if (caseInsensitive) {
      a = a.toLowerCase()
      b = b.toLowerCase()
    }

    return a === b || a === b + '/'
  }

  let match = possibleMatches.find(g => titleMatch(g, false))

  if (!match) {
    match = possibleMatches.find(g => titleMatch(g, true))
  }

  if (match) {
    if (pathParts.length > 1) {
      const rest = pathParts.slice(1)
      return filterGrouplikeByPath(match, rest)
    } else {
      return match
    }
  } else {
    console.warn(`Not found: "${firstPart}"`)
    return null
  }
}

export function removeGroupByPathString(playlist, pathString) {
  // Calls removeGroupByPath, taking a path string, rather than a parsed path.

  return removeGroupByPath(playlist, parsePathString(pathString))
}

export function removeGroupByPath(playlist, pathParts) {
  // Removes the group at the given path from the given playlist.

  const groupToRemove = filterGrouplikeByPath(playlist, pathParts)

  if (groupToRemove === null) {
    return
  }

  if (playlist === groupToRemove) {
    console.error(
      'You can\'t remove the playlist from itself! Instead, try --clear' +
      ' (shorthand -c).'
    )

    return
  }

  if (!(parentSymbol in groupToRemove)) {
    console.error(
      `Group ${pathParts.join('/')} doesn't have a parent, so we can't` +
      ' remove it from the playlist.'
    )

    return
  }

  const parent = groupToRemove[parentSymbol]

  const index = parent.items.indexOf(groupToRemove)

  if (index >= 0) {
    parent.items.splice(index, 1)
  } else {
    console.error(
      `Group ${pathParts.join('/')} doesn't exist, so we can't explicitly ` +
      'ignore it.'
    )
  }
}

export function getPlaylistTreeString(playlist, showTracks = false) {
  function recursive(group) {
    const groups = group.items.filter(x => isGroup(x))
    const nonGroups = group.items.filter(x => !isGroup(x))

    const childrenString = groups.map(group => {
      const name = group.name
      const groupString = recursive(group)

      if (groupString) {
        const indented = groupString.split('\n').map(l => '| ' + l).join('\n')
        return '\n' + name + '\n' + indented
      } else {
        return name
      }
    }).join('\n')

    let tracksString = ''
    if (showTracks) {
      tracksString = nonGroups.map(g => g.name).join('\n')
    }

    if (tracksString && childrenString) {
      return tracksString + '\n' + childrenString
    } else if (childrenString) {
      return childrenString
    } else if (tracksString) {
      return tracksString
    } else {
      return ''
    }
  }

  return recursive(playlist)
}

export function getItemPath(item) {
  if (item[parentSymbol]) {
    return [...getItemPath(item[parentSymbol]), item]
  } else {
    return [item]
  }
}

export function getItemPathString(item) {
  // Gets the playlist path of an item by following its parent chain.
  //
  // Returns a string in format Foo/Bar/Baz, where Foo and Bar are group
  // names, and Baz is the name of the item.
  //
  // Unnamed parents are given the name '(Unnamed)'.
  // Always ignores the root (top) group.
  //
  // Requires that the given item be from a playlist processed by
  // updateGroupFormat.

  // Check if the parent is not the top level group.
  // The top-level group is included in the return path as '/'.
  if (item[parentSymbol]) {
    const displayName = item.name || '(Unnamed)'

    if (item[parentSymbol][parentSymbol]) {
      return getItemPathString(item[parentSymbol]) + '/' + displayName
    } else {
      return '/' + displayName
    }
  } else {
    return '/'
  }
}

export function parsePathString(pathString) {
  const pathParts = pathString.split('/').filter(item => item.length)
  return pathParts
}

export function getTrackIndexInParent(track) {
  if (parentSymbol in track === false) {
    throw new Error(
      'getTrackIndexInParent called with a track that has no parent!'
    )
  }

  const parent = track[parentSymbol]

  let i = 0, foundTrack = false;
  for (; i < parent.items.length; i++) {
    // TODO: Port isSameTrack from http-music, if it makes sense - doing
    // so involves porting the [oldSymbol] property on all tracks and groups,
    // so may or may not be the right call. This function isn't used anywhere
    // in mtui so it'll take a little extra investigation.
    /* eslint-disable-next-line no-undef */
    if (isSameTrack(track, parent.items[i])) {
      foundTrack = true
      break
    }
  }

  if (foundTrack === false) {
    return [-1, parent.items.length]
  } else {
    return [i, parent.items.length]
  }
}

const nameWithoutTrackNumberSymbol = Symbol('Cached name without track number')
export function getNameWithoutTrackNumber(track) {
  // A "part" is a series of numeric digits, separated from other parts by
  // whitespace, dashes, and dots, always preceding either the first non-
  // numeric/separator character or (if there are no such characters) the
  // first word (i.e. last whitespace).
  const getNumberOfParts = ({ name }) => {
    name = name.replace(/^[-\s.]+$/, '')
    const match = name.match(/[^0-9-\s.]/)
    if (match) {
      if (match.index === 0) {
        return 0
      } else {
        name = name.slice(0, match.index)
      }
    } else if (name.includes(' ')) {
      name = name.slice(0, name.lastIndexOf(' '))
    } else {
      return 0
    }
    name = name.replace(/[-\s.]+$/, '')
    return name.split(/[-\s.]+/g).length
  }

  const removeParts = (name, numParts) => {
    const regex = new RegExp(String.raw`[-\s.]{0,}([0-9]+[-\s.]+){${numParts},${numParts}}`)
    return track.name.replace(regex, '')
  }

  // Despite this function returning a single string for one track, that value
  // depends on the names of all other tracks under the same parent. We still
  // store individual track -> name data on the track object, but the parent
  // gets an additional cache for the names of its children tracks as well as
  // the number of "parts" (the value directly based upon those names, and
  // useful in computing the name data for other children tracks).

  const parent = track[parentSymbol]
  if (parent) {
    const [trackNames, cachedNumParts] = parent[nameWithoutTrackNumberSymbol] || []
    const tracks = parent.items.filter(isTrack)
    if (trackNames && tracks.length === trackNames.length && tracks.every((t, i) => t.name === trackNames[i])) {
      const [, oldName, oldNumParts, cachedValue] = track[nameWithoutTrackNumberSymbol] || []
      if (cachedValue && track.name === oldName && cachedNumParts === oldNumParts) {
        return cachedValue
      } else {
        // Individual track cache outdated.
        const value = removeParts(track.name, cachedNumParts)
        track[nameWithoutTrackNumberSymbol] = [true, track.name, cachedNumParts, value]
        return value
      }
    } else {
      // Group (parent) cache outdated.
      const numParts = Math.min(...tracks.map(getNumberOfParts))
      parent[nameWithoutTrackNumberSymbol] = [tracks.map(t => t.name), numParts]
      // Parent changed so track cache changed is outdated too.
      const value = removeParts(track.name, numParts)
      track[nameWithoutTrackNumberSymbol] = [true, track.name, numParts, value]
      return value
    }
  } else {
    const [oldHadParent, oldName, , cachedValue] = track[nameWithoutTrackNumberSymbol] || []
    if (cachedValue && !oldHadParent && track.name === oldName) {
      return cachedValue
    } else {
      // Track cache outdated.
      const numParts = getNumberOfParts(track)
      const value = removeParts(track.name, numParts)
      track[nameWithoutTrackNumberSymbol] = [false, track.name, numParts, value]
      return value
    }
  }
}

export function isGroup(obj) {
  return !!(obj && obj.items)
}

export function isTrack(obj) {
  return !!(obj && obj.downloaderArg)
}

export function isPlayable(obj) {
  return isGroup(obj) || isTrack(obj)
}

export function isOpenable(obj) {
  return !!(obj && obj.url)
}


export function searchForItem(grouplike, value, preferredStartIndex = -1) {
  if (value.length) {
    // We prioritize searching past the index that the user opened the jump
    // element from (oldFocusedIndex). This is so that it's more practical
    // to do a "repeated" search, wherein the user searches for the same
    // value over and over, each time jumping to the next match, until they
    // have found the one they're looking for.

    const lower = value.toLowerCase()
    const getName = item => (item && item.name) ? item.name.toLowerCase().trim() : ''

    const testStartsWith = item => getName(item).startsWith(lower)
    const testIncludes = item => getName(item).includes(lower)

    const searchPastCurrentIndex = test => {
      const start = preferredStartIndex + 1
      const match = grouplike.items.slice(start).findIndex(test)
      if (match === -1) {
        return -1
      } else {
        return start + match
      }
    }

    const allIndexes = [
      searchPastCurrentIndex(testStartsWith),
      searchPastCurrentIndex(testIncludes),
      grouplike.items.findIndex(testStartsWith),
      grouplike.items.findIndex(testIncludes)
    ]

    const matchedIndex = allIndexes.find(value => value >= 0)
    if (typeof matchedIndex !== 'undefined') {
      return grouplike.items[matchedIndex]
    }
  }

  return null
}

export function getCorrespondingFileForItem(item, extension) {
  if (!(item && item.url)) {
    return null
  }

  const checkExtension = item => item.url && item.url.endsWith(extension)

  if (isPlayable(item)) {
    const parent = item[parentSymbol]

    if (!parent) {
      return null
    }

    const basename = path.basename(item.url, path.extname(item.url))
    return parent.items.find(item => checkExtension(item) && path.basename(item.url, extension) === basename)
  }

  if (checkExtension(item)) {
    return item
  }

  return null
}

export function getCorrespondingPlayableForFile(item) {
  if (!(item && item.url)) {
    return null
  }

  if (isPlayable(item)) {
    return item
  }

  const parent = item[parentSymbol]

  if (!parent) {
    return null
  }

  const basename = path.basename(item.url, path.extname(item.url))
  return parent.items.find(item => isPlayable(item) && path.basename(item.url, path.extname(item.url)) === basename)
}

export function getPathScore(path1, path2) {
  // This function is basically only used in findTrackObject, but it's kinda
  // huge and I need to test that it works outside of that context, so I'm
  // sticking it on the global scope. Feel free to steal for whatever your
  // weird future need for comparing any two paths is!
  //
  // path1 and path2 should be arrays of group names, according to the path
  // you'd follow to open the groups and access a contained track. They should
  // *not* include the track name, unless you want those to be considered a
  // valid place for the paths to cross over!
  //
  // --
  //
  // A path score is determined to be the number of groups which must be
  // traversed across the two paths to find a matching group name and then
  // reach the other track under that group. A lower score implies a closer
  // match (since score increases not with "closeness" but "separation").
  //
  // For example, these two paths are considered to have a score of zero
  // against each other ("T" represents the track):
  //
  //   X/B/C/T
  //   Y/B/C/T
  //
  // Their separation is zero because, starting from the closest (i.e. top)
  // group to either the provided track or the reference data track, it takes
  // zero additional steps to reach a group whose name is shared between the
  // two paths: those top groups already have the same name.
  //
  // The above example indicates that the pattern before the closest matching
  // path does not matter. Indeed, the actual length of the path could be
  // different (W/X/B/C versus Y/B/C for example), and the score would still
  // be the same. Parts of the path prepending the closest matching group
  // name are thus ommitted from following examples.
  //
  // These paths, on the other hand, have a score of one:
  //
  //   (...)/C/T
  //   (...)/C/D/T
  //
  // The closest matching name in this path is C. It is zero steps further
  // from the start of the first path (C is the start); on the other path,
  // it is one step further (D must be passed first). Therefore, the total
  // steps that must be travelled to reach the start of one path to the
  // start of the other by passing through the closest overlapping name is
  // one: 0 + 1 = 1.
  //
  // In determining which of two paths are a closer match to a provided
  // reference path, it's important to remember that a lower score (implying
  // less separation) is better. Though we'll see the following example is
  // probably more common across most music libraries, a reasonably natural
  // example of the path structures above occurring in a music library could
  // be this: an artist directory containing both albums and stray tracks,
  // where one track apparently appears as both a stray track file and in an
  // adjacent album directory; or, a mixtape which contains adjacent to its
  // mixed-segment track listing a folder of the unmixed segments.
  //
  // These paths have a score of two:
  //
  //   (...)/B/C/T
  //   (...)/B/D/T
  //
  // With the above examples, this one is fairly self explanatory. In this
  // case, the closest matching group, B, is one step away from the start
  // point (the first group before the track, i.e, the top name in the path)
  // in both paths. Summed, the distance (and thus the score) is two.
  //
  // This example demonstrates what is probably a more realistic case of two
  // tracks resembling each other (e.g. having the same name or source) but
  // not sharing the same path: if B represents an artist, and C & D stand in
  // place (in this example) of the names of that artist's albums, then it is
  // reasonable to say the directories for the album are slightly different
  // across the two paths. This could be the case for two users who ended up
  // naming the album directory differently, or for one user restoring from
  // their own backend/playlist after having adjusted the naming structure of
  // their music library. It's also possible that there could simply be two
  // albums by the same artist which contain a track of the same name; in
  // that case, the path score implementation is doing exactly its job by
  // indicating that these tracks would have a greater score (meaning further
  // separation) than when checking against the track belonging to the same
  // release. (If there is concern that such a track should not match at all
  // because it may be a remarkably different track, other factors of
  // resemblance -- position in album, duration, etc -- can be used to add
  // detail to the apparent level of resemblance then.)
  //
  // --
  //
  // A note on determining which name is the "closest" -- consider
  // the following two paths:
  //
  //   A/X/B/C/D/E/T
  //   A/Y/E/B/C/D/T
  //
  // There are many names which appear in both paths. So which do we treat
  // as the closest? Well, what we're looking for is the shortest path across
  // both paths, passing through at a particular name. To do this, we simply
  // calculate the score for each name in the intersection of both paths
  // (i.e. every name which shows up in both paths) using the same algorithm
  // described above (sum of the distance from the start of either path).
  // Then we take the lowest resultant score, and use that as the final score
  // which is returned out of this function.
  //
  // TODO: There are probably optimizations to be made as far as avoiding
  //       processing every overlapping name goes (particularly once it's
  //       determined that no other path could be determined), but honestly
  //       I'm pretty sure if I tried to write an algorithm taking *that*
  //       into account, I'd end up screwing it up. :P So for now, we just
  //       do a simple filter and reduce operation.
  //
  // If the intersection of the two paths is empty (i.e. there is no overlap),
  // we return the otherwise nonsense value, -1.

  const union = Array.from(new Set([...path1, ...path2]))
  const intersection = union.filter(
    name => path1.includes(name) && path2.includes(name))

  if (!intersection.length) {
    return -1
  }

  const reversed1 = path1.reverse()
  const reversed2 = path2.reverse()

  const scores = intersection.map(
    name => reversed1.indexOf(name) + reversed2.indexOf(name))

  return scores.reduce((a, b) => a < b ? a : b)
}

export function getNameScore(name1, name2) {
  // Pretty simple algorithm here: we're looking for the longest continuous
  // series of words which is shared between both names. The score is the
  // length of that series, so a higher score is better (and a zero score
  // means no overlap).

  // TODO:
  // This ain't perfect! Case example: User A has library structure:
  //
  //   Very Cool Album/
  //     01 Beans
  //
  // User B has library structure:
  //
  //   Very Cool Album/
  //     Very Cool Album- 01 Beans
  //
  // Now if user B queues 'Very Cool Album- 01 Beans', the search will match
  // the *group* 'Very Cool Album' on User A's end, because that name has a
  // 3-word match, in comparison to the track '01 Beans', which is only a
  // 2-word match. Not sure what a proper solution here would be, but probably
  // it'd involve somehow prioritizing series of words which match closer to
  // the end!

  // Split into chunks of word characters, taking out any non-word (\W)
  // characters between.
  const toWords = name => name.split(/\W+/)

  const words1 = toWords(name1)
  const words2 = toWords(name2)

  const getLongestMatch = (parse, against) => {
    let longestMatch = 0

    for (let i = 0; i < parse.length; i++) {
      const word = parse[i]

      for (let j = 0; j < against.length; j++) {
        if (against[j] !== word) {
          continue
        }

        let offset = 1
        while (
          parse[i + offset] &&
          against[i + offset] &&
          parse[i + offset] === against[j + offset]
        ) {
          offset++
        }

        if (offset > longestMatch) {
          longestMatch = offset
        }
      }
    }

    return longestMatch
  }

  return Math.max(
    getLongestMatch(words1, words2),
    getLongestMatch(words2, words1)
  )
}

export function findItemObject(referenceData, possibleChoices) {
  // Finds the item object in the provided choices which most closely resembles
  // the provided reference data. This is used for maintaining the identity of
  // item objects when reloading a playlist (see serialized-backend.js). It's
  // also usable in synchronizing the identity of items across linked clients
  // (see socket.js).

  // Reference data includes item NAME and item PATH (names of parent groups).
  // Specifics of how existing item objects are determined to resemble this
  // data are laid out next to the relevant implementation code.
  //
  // TODO: Should track number be considered here?
  // TODO: Should track "metadata" (duration, md5?) be considered too?
  //       This in particular prompts questions of what the purpose of matching
  //       tracks *is*, and in considering those I lean towards "no" here, but
  //       it's probably worth looking at more in the future. (TM.)

  function getItemPathScore(item) {
    if (!referenceData.path) {
      return null
    }

    const path1 = referenceData.path.slice()
    const path2 = getItemPath(item).slice(0, -1).map(group => group.name)
    return getPathScore(path1, path2)
  }

  function getItemNameScore(item) {
    const name1 = referenceData.name
    const name2 = item.name
    return getNameScore(name1, name2)
  }

  // The only items which will be considered at all are those which at least
  // partially match the reference name.
  const baselineResemble = possibleChoices.map(item => ({
    item,
    nameScore: getItemNameScore(item)
  })).filter(item => item.nameScore > 0)

  // If no item matches the baseline conditions for resemblance at all,
  // return null. It's up to the caller to decide what to do in this case,
  // e.g. reporting that no item was found, or creating a new item object
  // from the reference data altogether.
  if (!baselineResemble.length) {
    return null
  }

  // Find the "reasons" these items resemble the reference data; these will
  // be used as the factors in calculating which item resembles closest.
  const reasons = baselineResemble.map(({item, nameScore}) => ({
    item,
    pathScore: getItemPathScore(item),
    nameScore
  }))

  // TODO: Are there circumstances in which a strong path score should be
  //       prioritized in spite of weaker name score?

  // Sort by closest matching filenames first.
  reasons.sort((a, b) => b.nameScore - a.nameScore)

  // Filter only the best name matches.
  const bestNameScore = reasons[0].nameScore
  const bestName = reasons.filter(({ nameScore }) => nameScore === bestNameScore)

  // Then choose the best matching path.
  const sharePath = bestName.filter(({ pathScore }) => pathScore >= 0)
  const mostResembles = (sharePath.length
    ? sharePath.reduce((a, b) => a.pathScore < b.pathScore ? a : b)
    : reasons[0])

  return mostResembles.item
}

export function walkSharedStructure(modelGrouplike, ...additionalGrouplikesAndCallback) {
  // Recursively traverse (aka "walk") a model grouplike and follow the same
  // path through one or more additional grouplikes, running a callback with
  // the item at that path from each of the grouplikes (model and additional).

  const additionalGrouplikes = additionalGrouplikesAndFunction.slice(0, -1)
  const callback = additionalGrouplikesAndCallback[additionalGrouplikesAndFunction.length - 1]

  const recursive = (model, ...additional) => {
    for (let i = 0; i < model.items.length; i++) {
      const modelItem = model.items[i]
      const additionalItems = additional.map(a => a.items[i])
      callback(modelItem, ...additionalItems)

      if (isGroup(modelItem)) {
        recursive(modelItem, ...additionalItems)
      }
    }
  }
}
